agora inbox for [email protected]help / color / mirror / Atom feed
[PATCH v6 2/2] Provide relfilenode statistics 785+ messages / 1 participants [nested] [flat]
* [PATCH v6 2/2] Provide relfilenode statistics @ 2023-11-16 02:30 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2023-11-16 02:30 UTC (permalink / raw) We currently don’t have writes counters for relations. The reason is that we don’t have the relation OID when writing buffers out. Tracking writes per relfilenode would allow us to track/consolidate writes per relation. relfilenode stats is also beneficial for the "Split index and table statistics into different types of stats" work in progress: it would allow us to avoid additional branches in some situations. === Remarks === This is a POC patch. There is still work to do: there is more places we should add relfilenode counters, create more APIS to retrieve the relfilenode stats, the patch takes care of rewrite generated by TRUNCATE but there is more to care about like CLUSTER,VACUUM FULL. The new logic to retrieve stats in pg_statio_all_tables has been implemented only for the new blocks_written stat (we'd need to do the same for the existing buffer read / buffer hit if we agree on the approach implemented here). The goal of this patch is to start the discussion and agree on the design before moving forward. --- src/backend/access/rmgrdesc/xactdesc.c | 5 +- src/backend/catalog/storage.c | 8 ++ src/backend/catalog/system_functions.sql | 2 +- src/backend/catalog/system_views.sql | 5 +- src/backend/postmaster/checkpointer.c | 5 + src/backend/storage/buffer/bufmgr.c | 6 +- src/backend/storage/smgr/md.c | 8 ++ src/backend/utils/activity/pgstat.c | 56 +++++---- src/backend/utils/activity/pgstat_database.c | 12 +- src/backend/utils/activity/pgstat_function.c | 13 +- src/backend/utils/activity/pgstat_relation.c | 112 ++++++++++++++++-- src/backend/utils/activity/pgstat_replslot.c | 13 +- src/backend/utils/activity/pgstat_shmem.c | 19 +-- .../utils/activity/pgstat_subscription.c | 14 +-- src/backend/utils/activity/pgstat_xact.c | 66 ++++++++--- src/backend/utils/adt/pgstatfuncs.c | 34 +++++- src/include/access/tableam.h | 19 +++ src/include/access/xact.h | 1 + src/include/catalog/pg_proc.dat | 14 ++- src/include/pgstat.h | 37 ++++-- src/include/utils/pgstat_internal.h | 33 ++++-- src/test/recovery/t/029_stats_restart.pl | 40 +++---- .../recovery/t/030_stats_cleanup_replica.pl | 6 +- src/test/regress/expected/rules.out | 12 +- src/test/regress/expected/stats.out | 30 ++--- src/test/regress/sql/stats.sql | 30 ++--- src/test/subscription/t/026_stats.pl | 4 +- src/tools/pgindent/typedefs.list | 1 + 28 files changed, 443 insertions(+), 162 deletions(-) 4.2% src/backend/catalog/ 48.9% src/backend/utils/activity/ 6.0% src/backend/utils/adt/ 3.7% src/backend/ 3.0% src/include/access/ 3.1% src/include/catalog/ 5.1% src/include/utils/ 6.3% src/include/ 11.2% src/test/recovery/t/ 5.1% src/test/regress/expected/ diff --git a/src/backend/access/rmgrdesc/xactdesc.c b/src/backend/access/rmgrdesc/xactdesc.c index 889cb955c1..5a15cdd460 100644 --- a/src/backend/access/rmgrdesc/xactdesc.c +++ b/src/backend/access/rmgrdesc/xactdesc.c @@ -322,10 +322,11 @@ xact_desc_stats(StringInfo buf, const char *label, uint64 objid = ((uint64) dropped_stats[i].objid_hi) << 32 | dropped_stats[i].objid_lo; - appendStringInfo(buf, " %d/%u/%llu", + appendStringInfo(buf, " %d/%u/%llu/%u", dropped_stats[i].kind, dropped_stats[i].dboid, - (unsigned long long) objid); + (unsigned long long) objid, + dropped_stats[i].relfile); } } } diff --git a/src/backend/catalog/storage.c b/src/backend/catalog/storage.c index f56b3cc0f2..db6107cd90 100644 --- a/src/backend/catalog/storage.c +++ b/src/backend/catalog/storage.c @@ -33,6 +33,7 @@ #include "storage/smgr.h" #include "utils/hsearch.h" #include "utils/memutils.h" +#include "utils/pgstat_internal.h" #include "utils/rel.h" /* GUC variables */ @@ -152,6 +153,7 @@ RelationCreateStorage(RelFileLocator rlocator, char relpersistence, if (needs_wal) log_smgrcreate(&srel->smgr_rlocator.locator, MAIN_FORKNUM); + pgstat_create_transactional(PGSTAT_KIND_RELFILENODE, rlocator.dbOid, rlocator.spcOid, rlocator.relNumber); /* * Add the relation to the list of stuff to delete at abort, if we are * asked to do so. @@ -227,6 +229,8 @@ RelationDropStorage(Relation rel) * for now I'll keep the logic simple. */ + pgstat_drop_transactional(PGSTAT_KIND_RELFILENODE, rel->rd_locator.dbOid, rel->rd_locator.spcOid, rel->rd_locator.relNumber); + RelationCloseSmgr(rel); } @@ -253,6 +257,9 @@ RelationPreserveStorage(RelFileLocator rlocator, bool atCommit) PendingRelDelete *pending; PendingRelDelete *prev; PendingRelDelete *next; + PgStat_SubXactStatus *xact_state; + + xact_state = pgStatXactStack; prev = NULL; for (pending = pendingDeletes; pending != NULL; pending = next) @@ -267,6 +274,7 @@ RelationPreserveStorage(RelFileLocator rlocator, bool atCommit) else pendingDeletes = next; pfree(pending); + PgStat_RemoveRelFileNodeFromDroppedStats(xact_state, rlocator); /* prev does not change */ } else diff --git a/src/backend/catalog/system_functions.sql b/src/backend/catalog/system_functions.sql index 20d3b9b73f..3b9e822de1 100644 --- a/src/backend/catalog/system_functions.sql +++ b/src/backend/catalog/system_functions.sql @@ -718,7 +718,7 @@ REVOKE EXECUTE ON FUNCTION pg_stat_reset_single_function_counters(oid) FROM publ REVOKE EXECUTE ON FUNCTION pg_stat_reset_replication_slot(text) FROM public; -REVOKE EXECUTE ON FUNCTION pg_stat_have_stats(text, oid, int8) FROM public; +REVOKE EXECUTE ON FUNCTION pg_stat_have_stats(text, oid, int8, oid) FROM public; REVOKE EXECUTE ON FUNCTION pg_stat_reset_subscription_stats(oid) FROM public; diff --git a/src/backend/catalog/system_views.sql b/src/backend/catalog/system_views.sql index 3456b821bc..7e582399bb 100644 --- a/src/backend/catalog/system_views.sql +++ b/src/backend/catalog/system_views.sql @@ -746,6 +746,7 @@ CREATE VIEW pg_statio_all_tables AS C.relname AS relname, pg_stat_get_blocks_fetched(C.oid) - pg_stat_get_blocks_hit(C.oid) AS heap_blks_read, + pg_stat_get_blocks_written(C.oid) + pg_stat_get_relfilenode_blocks_written(d.oid, CASE WHEN C.reltablespace <> 0 THEN C.reltablespace ELSE d.dattablespace END, C.relfilenode) AS heap_blks_written, pg_stat_get_blocks_hit(C.oid) AS heap_blks_hit, I.idx_blks_read AS idx_blks_read, I.idx_blks_hit AS idx_blks_hit, @@ -754,7 +755,7 @@ CREATE VIEW pg_statio_all_tables AS pg_stat_get_blocks_hit(T.oid) AS toast_blks_hit, X.idx_blks_read AS tidx_blks_read, X.idx_blks_hit AS tidx_blks_hit - FROM pg_class C LEFT JOIN + FROM pg_database d, pg_class C LEFT JOIN pg_class T ON C.reltoastrelid = T.oid LEFT JOIN pg_namespace N ON (N.oid = C.relnamespace) LEFT JOIN LATERAL ( @@ -771,7 +772,7 @@ CREATE VIEW pg_statio_all_tables AS sum(pg_stat_get_blocks_hit(indexrelid))::bigint AS idx_blks_hit FROM pg_index WHERE indrelid = T.oid ) X ON true - WHERE C.relkind IN ('r', 't', 'm'); + WHERE C.relkind IN ('r', 't', 'm') AND d.datname = current_database(); CREATE VIEW pg_statio_sys_tables AS SELECT * FROM pg_statio_all_tables diff --git a/src/backend/postmaster/checkpointer.c b/src/backend/postmaster/checkpointer.c index 982572a75d..808c9d52d5 100644 --- a/src/backend/postmaster/checkpointer.c +++ b/src/backend/postmaster/checkpointer.c @@ -530,6 +530,11 @@ CheckpointerMain(char *startup_data, size_t startup_data_len) /* Report pending statistics to the cumulative stats system */ pgstat_report_checkpointer(); pgstat_report_wal(true); + /* + * No need to check for transaction state in checkpointer before + * calling pgstat_report_stat(). + */ + pgstat_report_stat(true); /* * If any checkpoint flags have been set, redo the loop to handle the diff --git a/src/backend/storage/buffer/bufmgr.c b/src/backend/storage/buffer/bufmgr.c index 0f02bf62fa..38cd5dddb6 100644 --- a/src/backend/storage/buffer/bufmgr.c +++ b/src/backend/storage/buffer/bufmgr.c @@ -1159,9 +1159,9 @@ PinBufferForBlock(Relation rel, * WaitReadBuffers() (so, not for hits, and not for buffers that are * zeroed instead), the per-relation stats always count them. */ - pgstat_count_buffer_read(rel); + pgstat_report_relfilenode_buffer_read(rel); if (*foundPtr) - pgstat_count_buffer_hit(rel); + pgstat_report_relfilenode_buffer_hit(rel); } if (*foundPtr) { @@ -3895,6 +3895,8 @@ FlushBuffer(BufferDesc *buf, SMgrRelation reln, IOObject io_object, pgBufferUsage.shared_blks_written++; + pgstat_report_relfilenode_blks_written(reln->smgr_rlocator.locator); + /* * Mark the buffer as clean (unless BM_JUST_DIRTIED has become set) and * end the BM_IO_IN_PROGRESS state. diff --git a/src/backend/storage/smgr/md.c b/src/backend/storage/smgr/md.c index cc8a80ee96..0f71e9016a 100644 --- a/src/backend/storage/smgr/md.c +++ b/src/backend/storage/smgr/md.c @@ -38,6 +38,7 @@ #include "storage/smgr.h" #include "storage/sync.h" #include "utils/memutils.h" +#include "utils/pgstat_internal.h" /* * The magnetic disk storage manager keeps track of open file @@ -1468,12 +1469,16 @@ DropRelationFiles(RelFileLocator *delrels, int ndelrels, bool isRedo) { SMgrRelation *srels; int i; + int not_freed_count = 0; srels = palloc(sizeof(SMgrRelation) * ndelrels); for (i = 0; i < ndelrels; i++) { SMgrRelation srel = smgropen(delrels[i], INVALID_PROC_NUMBER); + if (!pgstat_drop_entry(PGSTAT_KIND_RELFILENODE, delrels[i].dbOid, delrels[i].spcOid, delrels[i].relNumber)) + not_freed_count++; + if (isRedo) { ForkNumber fork; @@ -1484,6 +1489,9 @@ DropRelationFiles(RelFileLocator *delrels, int ndelrels, bool isRedo) srels[i] = srel; } + if (not_freed_count > 0) + pgstat_request_entry_refs_gc(); + smgrdounlinkall(srels, ndelrels, isRedo); for (i = 0; i < ndelrels; i++) diff --git a/src/backend/utils/activity/pgstat.c b/src/backend/utils/activity/pgstat.c index ea8c5691e8..151ba38aa7 100644 --- a/src/backend/utils/activity/pgstat.c +++ b/src/backend/utils/activity/pgstat.c @@ -307,6 +307,19 @@ static const PgStat_KindInfo pgstat_kind_builtin_infos[PGSTAT_KIND_BUILTIN_SIZE] .delete_pending_cb = pgstat_relation_delete_pending_cb, }, + [PGSTAT_KIND_RELFILENODE] = { + .name = "relfilenode", + + .fixed_amount = false, + + .shared_size = sizeof(PgStatShared_RelFileNode), + .shared_data_off = offsetof(PgStatShared_RelFileNode, stats), + .shared_data_len = sizeof(((PgStatShared_RelFileNode *) 0)->stats), + .pending_size = sizeof(PgStat_StatRelFileNodeEntry), + + .flush_pending_cb = pgstat_relfilenode_flush_cb, + }, + [PGSTAT_KIND_FUNCTION] = { .name = "function", @@ -756,7 +769,7 @@ pgstat_report_stat(bool force) partial_flush = false; - /* flush database / relation / function / ... stats */ + /* flush database / relation / function / relfilenode / ... stats */ partial_flush |= pgstat_flush_pending_entries(nowait); /* flush of fixed-numbered stats */ @@ -845,7 +858,7 @@ pgstat_reset_counters(void) * GRANT system. */ void -pgstat_reset(PgStat_Kind kind, Oid dboid, uint64 objid) +pgstat_reset(PgStat_Kind kind, Oid dboid, uint64 objid, RelFileNumber relfile) { const PgStat_KindInfo *kind_info = pgstat_get_kind_info(kind); TimestampTz ts = GetCurrentTimestamp(); @@ -854,7 +867,7 @@ pgstat_reset(PgStat_Kind kind, Oid dboid, uint64 objid) Assert(!pgstat_get_kind_info(kind)->fixed_amount); /* reset the "single counter" */ - pgstat_reset_entry(kind, dboid, objid, ts); + pgstat_reset_entry(kind, dboid, objid, relfile, ts); if (!kind_info->accessed_across_databases) pgstat_reset_database_timestamp(dboid, ts); @@ -925,7 +938,7 @@ pgstat_clear_snapshot(void) } void * -pgstat_fetch_entry(PgStat_Kind kind, Oid dboid, uint64 objid) +pgstat_fetch_entry(PgStat_Kind kind, Oid dboid, uint64 objid, RelFileNumber relfile) { PgStat_HashKey key; PgStat_EntryRef *entry_ref; @@ -944,6 +957,7 @@ pgstat_fetch_entry(PgStat_Kind kind, Oid dboid, uint64 objid) key.kind = kind; key.dboid = dboid; key.objid = objid; + key.relfile = relfile; /* if we need to build a full snapshot, do so */ if (pgstat_fetch_consistency == PGSTAT_FETCH_CONSISTENCY_SNAPSHOT) @@ -969,7 +983,7 @@ pgstat_fetch_entry(PgStat_Kind kind, Oid dboid, uint64 objid) pgStatLocal.snapshot.mode = pgstat_fetch_consistency; - entry_ref = pgstat_get_entry_ref(kind, dboid, objid, false, NULL); + entry_ref = pgstat_get_entry_ref(kind, dboid, objid, relfile, false, NULL); if (entry_ref == NULL || entry_ref->shared_entry->dropped) { @@ -1038,13 +1052,13 @@ pgstat_get_stat_snapshot_timestamp(bool *have_snapshot) } bool -pgstat_have_entry(PgStat_Kind kind, Oid dboid, uint64 objid) +pgstat_have_entry(PgStat_Kind kind, Oid dboid, uint64 objid, RelFileNumber relfile) { /* fixed-numbered stats always exist */ if (pgstat_get_kind_info(kind)->fixed_amount) return true; - return pgstat_get_entry_ref(kind, dboid, objid, false, NULL) != NULL; + return pgstat_get_entry_ref(kind, dboid, objid, relfile, false, NULL) != NULL; } /* @@ -1259,7 +1273,8 @@ pgstat_build_snapshot_fixed(PgStat_Kind kind) * created, false otherwise. */ PgStat_EntryRef * -pgstat_prep_pending_entry(PgStat_Kind kind, Oid dboid, uint64 objid, bool *created_entry) +pgstat_prep_pending_entry(PgStat_Kind kind, Oid dboid, uint64 objid, + RelFileNumber relfile, bool *created_entry) { PgStat_EntryRef *entry_ref; @@ -1274,7 +1289,7 @@ pgstat_prep_pending_entry(PgStat_Kind kind, Oid dboid, uint64 objid, bool *creat ALLOCSET_SMALL_SIZES); } - entry_ref = pgstat_get_entry_ref(kind, dboid, objid, + entry_ref = pgstat_get_entry_ref(kind, dboid, objid, relfile, true, created_entry); if (entry_ref->pending == NULL) @@ -1297,11 +1312,11 @@ pgstat_prep_pending_entry(PgStat_Kind kind, Oid dboid, uint64 objid, bool *creat * that it shouldn't be needed. */ PgStat_EntryRef * -pgstat_fetch_pending_entry(PgStat_Kind kind, Oid dboid, uint64 objid) +pgstat_fetch_pending_entry(PgStat_Kind kind, Oid dboid, uint64 objid, RelFileNumber relfile) { PgStat_EntryRef *entry_ref; - entry_ref = pgstat_get_entry_ref(kind, dboid, objid, false, NULL); + entry_ref = pgstat_get_entry_ref(kind, dboid, objid, relfile, false, NULL); if (entry_ref == NULL || entry_ref->pending == NULL) return NULL; @@ -1330,7 +1345,7 @@ pgstat_delete_pending_entry(PgStat_EntryRef *entry_ref) } /* - * Flush out pending stats for database objects (databases, relations, + * Flush out pending stats for database objects (databases, relations, relfilenodes, * functions). */ static bool @@ -1650,9 +1665,10 @@ pgstat_write_statsfile(XLogRecPtr redo) */ if (!pgstat_is_kind_valid(ps->key.kind)) { - elog(WARNING, "found unknown stats entry %u/%u/%llu", + elog(WARNING, "found unknown stats entry %u/%u/%llu/%u", ps->key.kind, ps->key.dboid, - (unsigned long long) ps->key.objid); + (unsigned long long) ps->key.objid, + ps->key.relfile); continue; } @@ -1888,9 +1904,9 @@ pgstat_read_statsfile(XLogRecPtr redo) if (!pgstat_is_kind_valid(key.kind)) { - elog(WARNING, "invalid stats kind for entry %u/%u/%llu of type %c", + elog(WARNING, "invalid stats kind for entry %u/%u/%llu/%u of type %c", key.kind, key.dboid, - (unsigned long long) key.objid, t); + (unsigned long long) key.objid, key.relfile, t); goto error; } } @@ -1961,9 +1977,9 @@ pgstat_read_statsfile(XLogRecPtr redo) if (found) { dshash_release_lock(pgStatLocal.shared_hash, p); - elog(WARNING, "found duplicate stats entry %u/%u/%llu of type %c", + elog(WARNING, "found duplicate stats entry %u/%u/%llu/%u of type %c", key.kind, key.dboid, - (unsigned long long) key.objid, t); + (unsigned long long) key.objid, key.relfile, t); goto error; } @@ -1974,9 +1990,9 @@ pgstat_read_statsfile(XLogRecPtr redo) pgstat_get_entry_data(key.kind, header), pgstat_get_entry_len(key.kind))) { - elog(WARNING, "could not read data for entry %u/%u/%llu of type %c", + elog(WARNING, "could not read data for entry %u/%u/%llu/%u of type %c", key.kind, key.dboid, - (unsigned long long) key.objid, t); + (unsigned long long) key.objid, key.relfile, t); goto error; } diff --git a/src/backend/utils/activity/pgstat_database.c b/src/backend/utils/activity/pgstat_database.c index 29bc090974..cf77f2dbdb 100644 --- a/src/backend/utils/activity/pgstat_database.c +++ b/src/backend/utils/activity/pgstat_database.c @@ -43,7 +43,7 @@ static PgStat_Counter pgLastSessionReportTime = 0; void pgstat_drop_database(Oid databaseid) { - pgstat_drop_transactional(PGSTAT_KIND_DATABASE, databaseid, InvalidOid); + pgstat_drop_transactional(PGSTAT_KIND_DATABASE, databaseid, InvalidOid, InvalidOid); } /* @@ -66,7 +66,7 @@ pgstat_report_autovac(Oid dboid) * operation so it doesn't matter if we get blocked here a little. */ entry_ref = pgstat_get_entry_ref_locked(PGSTAT_KIND_DATABASE, - dboid, InvalidOid, false); + dboid, InvalidOid, InvalidOid, false); dbentry = (PgStatShared_Database *) entry_ref->shared_stats; dbentry->stats.last_autovac_time = GetCurrentTimestamp(); @@ -150,7 +150,7 @@ pgstat_report_checksum_failures_in_db(Oid dboid, int failurecount) * common enough for that to be a problem. */ entry_ref = - pgstat_get_entry_ref_locked(PGSTAT_KIND_DATABASE, dboid, InvalidOid, false); + pgstat_get_entry_ref_locked(PGSTAT_KIND_DATABASE, dboid, InvalidOid, InvalidOid, false); sharedent = (PgStatShared_Database *) entry_ref->shared_stats; sharedent->stats.checksum_failures += failurecount; @@ -242,7 +242,7 @@ PgStat_StatDBEntry * pgstat_fetch_stat_dbentry(Oid dboid) { return (PgStat_StatDBEntry *) - pgstat_fetch_entry(PGSTAT_KIND_DATABASE, dboid, InvalidOid); + pgstat_fetch_entry(PGSTAT_KIND_DATABASE, dboid, InvalidOid, InvalidOid); } void @@ -341,7 +341,7 @@ pgstat_prep_database_pending(Oid dboid) Assert(!OidIsValid(dboid) || OidIsValid(MyDatabaseId)); entry_ref = pgstat_prep_pending_entry(PGSTAT_KIND_DATABASE, dboid, InvalidOid, - NULL); + InvalidOid, NULL); return entry_ref->pending; } @@ -357,7 +357,7 @@ pgstat_reset_database_timestamp(Oid dboid, TimestampTz ts) PgStatShared_Database *dbentry; dbref = pgstat_get_entry_ref_locked(PGSTAT_KIND_DATABASE, MyDatabaseId, InvalidOid, - false); + InvalidOid, false); dbentry = (PgStatShared_Database *) dbref->shared_stats; dbentry->stats.stat_reset_timestamp = ts; diff --git a/src/backend/utils/activity/pgstat_function.c b/src/backend/utils/activity/pgstat_function.c index d26da551a4..440e44e300 100644 --- a/src/backend/utils/activity/pgstat_function.c +++ b/src/backend/utils/activity/pgstat_function.c @@ -46,7 +46,8 @@ pgstat_create_function(Oid proid) { pgstat_create_transactional(PGSTAT_KIND_FUNCTION, MyDatabaseId, - proid); + proid, + InvalidOid); } /* @@ -61,7 +62,8 @@ pgstat_drop_function(Oid proid) { pgstat_drop_transactional(PGSTAT_KIND_FUNCTION, MyDatabaseId, - proid); + proid, + InvalidOid); } /* @@ -86,6 +88,7 @@ pgstat_init_function_usage(FunctionCallInfo fcinfo, entry_ref = pgstat_prep_pending_entry(PGSTAT_KIND_FUNCTION, MyDatabaseId, fcinfo->flinfo->fn_oid, + InvalidOid, &created_entry); /* @@ -113,7 +116,7 @@ pgstat_init_function_usage(FunctionCallInfo fcinfo, if (!SearchSysCacheExists1(PROCOID, ObjectIdGetDatum(fcinfo->flinfo->fn_oid))) { pgstat_drop_entry(PGSTAT_KIND_FUNCTION, MyDatabaseId, - fcinfo->flinfo->fn_oid); + fcinfo->flinfo->fn_oid, InvalidOid); ereport(ERROR, errcode(ERRCODE_UNDEFINED_FUNCTION), errmsg("function call to dropped function")); } @@ -224,7 +227,7 @@ find_funcstat_entry(Oid func_id) { PgStat_EntryRef *entry_ref; - entry_ref = pgstat_fetch_pending_entry(PGSTAT_KIND_FUNCTION, MyDatabaseId, func_id); + entry_ref = pgstat_fetch_pending_entry(PGSTAT_KIND_FUNCTION, MyDatabaseId, func_id, InvalidOid); if (entry_ref) return entry_ref->pending; @@ -239,5 +242,5 @@ PgStat_StatFuncEntry * pgstat_fetch_stat_funcentry(Oid func_id) { return (PgStat_StatFuncEntry *) - pgstat_fetch_entry(PGSTAT_KIND_FUNCTION, MyDatabaseId, func_id); + pgstat_fetch_entry(PGSTAT_KIND_FUNCTION, MyDatabaseId, func_id, InvalidOid); } diff --git a/src/backend/utils/activity/pgstat_relation.c b/src/backend/utils/activity/pgstat_relation.c index faba8b64d2..cc71c23760 100644 --- a/src/backend/utils/activity/pgstat_relation.c +++ b/src/backend/utils/activity/pgstat_relation.c @@ -43,6 +43,7 @@ typedef struct TwoPhasePgStatRecord static PgStat_TableStatus *pgstat_prep_relation_pending(Oid rel_id, bool isshared); +PgStat_StatRelFileNodeEntry *pgstat_prep_relfilenode_pending(RelFileLocator locator); static void add_tabstat_xact_level(PgStat_TableStatus *pgstat_info, int nest_level); static void ensure_tabstat_xact_level(PgStat_TableStatus *pgstat_info); static void save_truncdrop_counters(PgStat_TableXactStatus *trans, bool is_drop); @@ -68,6 +69,7 @@ pgstat_copy_relation_stats(Relation dst, Relation src) dst_ref = pgstat_get_entry_ref_locked(PGSTAT_KIND_RELATION, dst->rd_rel->relisshared ? InvalidOid : MyDatabaseId, RelationGetRelid(dst), + InvalidOid, false); dstshstats = (PgStatShared_Relation *) dst_ref->shared_stats; @@ -169,7 +171,7 @@ pgstat_create_relation(Relation rel) { pgstat_create_transactional(PGSTAT_KIND_RELATION, rel->rd_rel->relisshared ? InvalidOid : MyDatabaseId, - RelationGetRelid(rel)); + RelationGetRelid(rel), InvalidOid); } /* @@ -183,7 +185,7 @@ pgstat_drop_relation(Relation rel) pgstat_drop_transactional(PGSTAT_KIND_RELATION, rel->rd_rel->relisshared ? InvalidOid : MyDatabaseId, - RelationGetRelid(rel)); + RelationGetRelid(rel), InvalidOid); if (!pgstat_should_count_relation(rel)) return; @@ -224,7 +226,7 @@ pgstat_report_vacuum(Oid tableoid, bool shared, /* block acquiring lock for the same reason as pgstat_report_autovac() */ entry_ref = pgstat_get_entry_ref_locked(PGSTAT_KIND_RELATION, - dboid, tableoid, false); + dboid, tableoid, InvalidOid, false); shtabentry = (PgStatShared_Relation *) entry_ref->shared_stats; tabentry = &shtabentry->stats; @@ -317,6 +319,7 @@ pgstat_report_analyze(Relation rel, /* block acquiring lock for the same reason as pgstat_report_autovac() */ entry_ref = pgstat_get_entry_ref_locked(PGSTAT_KIND_RELATION, dboid, RelationGetRelid(rel), + InvalidOid, false); /* can't get dropped while accessed */ Assert(entry_ref != NULL && entry_ref->shared_stats != NULL); @@ -457,6 +460,19 @@ pgstat_fetch_stat_tabentry(Oid relid) return pgstat_fetch_stat_tabentry_ext(IsSharedRelation(relid), relid); } +/* + * Support function for the SQL-callable pgstat* functions. Returns + * the collected statistics for one relfilenode or NULL. NULL doesn't mean + * that the relfilenode doesn't exist, just that there are no statistics, so the + * caller is better off to report ZERO instead. + */ +PgStat_StatRelFileNodeEntry * +pgstat_fetch_stat_relfilenodeentry(Oid dboid, Oid spcOid, RelFileNumber relfile) +{ + return (PgStat_StatRelFileNodeEntry *) + pgstat_fetch_entry(PGSTAT_KIND_RELFILENODE, dboid, spcOid, relfile); +} + /* * More efficient version of pgstat_fetch_stat_tabentry(), allowing to specify * whether the to-be-accessed table is a shared relation or not. @@ -467,7 +483,7 @@ pgstat_fetch_stat_tabentry_ext(bool shared, Oid reloid) Oid dboid = (shared ? InvalidOid : MyDatabaseId); return (PgStat_StatTabEntry *) - pgstat_fetch_entry(PGSTAT_KIND_RELATION, dboid, reloid); + pgstat_fetch_entry(PGSTAT_KIND_RELATION, dboid, reloid, InvalidOid); } /* @@ -490,10 +506,10 @@ find_tabstat_entry(Oid rel_id) PgStat_TableStatus *tabentry = NULL; PgStat_TableStatus *tablestatus = NULL; - entry_ref = pgstat_fetch_pending_entry(PGSTAT_KIND_RELATION, MyDatabaseId, rel_id); + entry_ref = pgstat_fetch_pending_entry(PGSTAT_KIND_RELATION, MyDatabaseId, rel_id, InvalidOid); if (!entry_ref) { - entry_ref = pgstat_fetch_pending_entry(PGSTAT_KIND_RELATION, InvalidOid, rel_id); + entry_ref = pgstat_fetch_pending_entry(PGSTAT_KIND_RELATION, InvalidOid, rel_id, InvalidOid); if (!entry_ref) return tablestatus; } @@ -877,6 +893,38 @@ pgstat_relation_flush_cb(PgStat_EntryRef *entry_ref, bool nowait) return true; } +/* + * Flush out pending stats for the relfilenode entry + * + * If nowait is true, this function returns false if lock could not + * immediately acquired, otherwise true is returned. + */ +bool +pgstat_relfilenode_flush_cb(PgStat_EntryRef *entry_ref, bool nowait) +{ + PgStatShared_RelFileNode *sharedent; + PgStat_StatRelFileNodeEntry *pendingent; + + pendingent = (PgStat_StatRelFileNodeEntry *) entry_ref->pending; + sharedent = (PgStatShared_RelFileNode *) entry_ref->shared_stats; + + if (!pgstat_lock_entry(entry_ref, nowait)) + return false; + +#define PGSTAT_ACCUM_RELFILENODECOUNT(item) \ + (sharedent)->stats.item += (pendingent)->item + + PGSTAT_ACCUM_RELFILENODECOUNT(blocks_fetched); + PGSTAT_ACCUM_RELFILENODECOUNT(blocks_hit); + PGSTAT_ACCUM_RELFILENODECOUNT(blocks_written); + + pgstat_unlock_entry(entry_ref); + + memset(pendingent, 0, sizeof(*pendingent)); + + return true; +} + void pgstat_relation_delete_pending_cb(PgStat_EntryRef *entry_ref) { @@ -898,7 +946,7 @@ pgstat_prep_relation_pending(Oid rel_id, bool isshared) entry_ref = pgstat_prep_pending_entry(PGSTAT_KIND_RELATION, isshared ? InvalidOid : MyDatabaseId, - rel_id, NULL); + rel_id, InvalidOid, NULL); pending = entry_ref->pending; pending->id = rel_id; pending->shared = isshared; @@ -906,6 +954,56 @@ pgstat_prep_relation_pending(Oid rel_id, bool isshared) return pending; } +PgStat_StatRelFileNodeEntry * +pgstat_prep_relfilenode_pending(RelFileLocator locator) +{ + PgStat_EntryRef *entry_ref; + + entry_ref = pgstat_prep_pending_entry(PGSTAT_KIND_RELFILENODE, locator.dbOid, + locator.spcOid, locator.relNumber, NULL); + + return entry_ref->pending; +} + +void +pgstat_report_relfilenode_blks_written(RelFileLocator locator) +{ + PgStat_StatRelFileNodeEntry *relfileentry = NULL; + + relfileentry = pgstat_prep_relfilenode_pending(locator); + + if (relfileentry) + relfileentry->blocks_written++; +} + +void +pgstat_report_relfilenode_buffer_read(Relation reln) +{ + PgStat_StatRelFileNodeEntry *relfileentry = NULL; + + /* For relation stats to survive after a rewrite */ + pgstat_count_buffer_read(reln); + + relfileentry = pgstat_prep_relfilenode_pending(reln->rd_locator); + + if (relfileentry) + relfileentry->blocks_fetched++; +} + +void +pgstat_report_relfilenode_buffer_hit(Relation reln) +{ + PgStat_StatRelFileNodeEntry *relfileentry = NULL; + + /* For relation stats to survive after a rewrite */ + pgstat_count_buffer_hit(reln); + + relfileentry = pgstat_prep_relfilenode_pending(reln->rd_locator); + + if (relfileentry) + relfileentry->blocks_hit++; +} + /* * add a new (sub)transaction state record */ diff --git a/src/backend/utils/activity/pgstat_replslot.c b/src/backend/utils/activity/pgstat_replslot.c index ddf2ab9928..da7016313e 100644 --- a/src/backend/utils/activity/pgstat_replslot.c +++ b/src/backend/utils/activity/pgstat_replslot.c @@ -62,7 +62,7 @@ pgstat_reset_replslot(const char *name) */ if (SlotIsLogical(slot)) pgstat_reset(PGSTAT_KIND_REPLSLOT, InvalidOid, - ReplicationSlotIndex(slot)); + ReplicationSlotIndex(slot), InvalidOid); LWLockRelease(ReplicationSlotControlLock); } @@ -82,7 +82,7 @@ pgstat_report_replslot(ReplicationSlot *slot, const PgStat_StatReplSlotEntry *re PgStat_StatReplSlotEntry *statent; entry_ref = pgstat_get_entry_ref_locked(PGSTAT_KIND_REPLSLOT, InvalidOid, - ReplicationSlotIndex(slot), false); + ReplicationSlotIndex(slot), InvalidOid, false); shstatent = (PgStatShared_ReplSlot *) entry_ref->shared_stats; statent = &shstatent->stats; @@ -116,7 +116,7 @@ pgstat_create_replslot(ReplicationSlot *slot) Assert(LWLockHeldByMeInMode(ReplicationSlotAllocationLock, LW_EXCLUSIVE)); entry_ref = pgstat_get_entry_ref_locked(PGSTAT_KIND_REPLSLOT, InvalidOid, - ReplicationSlotIndex(slot), false); + ReplicationSlotIndex(slot), InvalidOid, false); shstatent = (PgStatShared_ReplSlot *) entry_ref->shared_stats; /* @@ -146,7 +146,7 @@ void pgstat_acquire_replslot(ReplicationSlot *slot) { pgstat_get_entry_ref(PGSTAT_KIND_REPLSLOT, InvalidOid, - ReplicationSlotIndex(slot), true, NULL); + ReplicationSlotIndex(slot), InvalidOid, true, NULL); } /* @@ -158,7 +158,7 @@ pgstat_drop_replslot(ReplicationSlot *slot) Assert(LWLockHeldByMeInMode(ReplicationSlotAllocationLock, LW_EXCLUSIVE)); if (!pgstat_drop_entry(PGSTAT_KIND_REPLSLOT, InvalidOid, - ReplicationSlotIndex(slot))) + ReplicationSlotIndex(slot), InvalidOid)) pgstat_request_entry_refs_gc(); } @@ -178,7 +178,7 @@ pgstat_fetch_replslot(NameData slotname) if (idx != -1) slotentry = (PgStat_StatReplSlotEntry *) pgstat_fetch_entry(PGSTAT_KIND_REPLSLOT, - InvalidOid, idx); + InvalidOid, idx, InvalidOid); LWLockRelease(ReplicationSlotControlLock); @@ -210,6 +210,7 @@ pgstat_replslot_from_serialized_name_cb(const NameData *name, PgStat_HashKey *ke key->kind = PGSTAT_KIND_REPLSLOT; key->dboid = InvalidOid; key->objid = idx; + key->relfile = InvalidOid; return true; } diff --git a/src/backend/utils/activity/pgstat_shmem.c b/src/backend/utils/activity/pgstat_shmem.c index c1b7ff76b1..82f955a0b2 100644 --- a/src/backend/utils/activity/pgstat_shmem.c +++ b/src/backend/utils/activity/pgstat_shmem.c @@ -429,8 +429,8 @@ pgstat_get_entry_ref_cached(PgStat_HashKey key, PgStat_EntryRef **entry_ref_p) * if the entry is newly created, false otherwise. */ PgStat_EntryRef * -pgstat_get_entry_ref(PgStat_Kind kind, Oid dboid, uint64 objid, bool create, - bool *created_entry) +pgstat_get_entry_ref(PgStat_Kind kind, Oid dboid, uint64 objid, RelFileNumber relfile, + bool create, bool *created_entry) { PgStat_HashKey key; PgStatShared_HashEntry *shhashent; @@ -443,6 +443,7 @@ pgstat_get_entry_ref(PgStat_Kind kind, Oid dboid, uint64 objid, bool create, key.kind = kind; key.dboid = dboid; key.objid = objid; + key.relfile = relfile; /* * passing in created_entry only makes sense if we possibly could create @@ -652,12 +653,12 @@ pgstat_unlock_entry(PgStat_EntryRef *entry_ref) */ PgStat_EntryRef * pgstat_get_entry_ref_locked(PgStat_Kind kind, Oid dboid, uint64 objid, - bool nowait) + RelFileNumber relfile, bool nowait) { PgStat_EntryRef *entry_ref; /* find shared table stats entry corresponding to the local entry */ - entry_ref = pgstat_get_entry_ref(kind, dboid, objid, true, NULL); + entry_ref = pgstat_get_entry_ref(kind, dboid, objid, relfile, true, NULL); /* lock the shared entry to protect the content, skip if failed */ if (!pgstat_lock_entry(entry_ref, nowait)) @@ -827,10 +828,11 @@ pgstat_drop_entry_internal(PgStatShared_HashEntry *shent, */ if (shent->dropped) elog(ERROR, - "trying to drop stats entry already dropped: kind=%s dboid=%u objid=%llu refcount=%u", + "trying to drop stats entry already dropped: kind=%s dboid=%u objid=%llu relfile=%u refcount=%u", pgstat_get_kind_info(shent->key.kind)->name, shent->key.dboid, (unsigned long long) shent->key.objid, + shent->key.relfile, pg_atomic_read_u32(&shent->refcount)); shent->dropped = true; @@ -913,7 +915,7 @@ pgstat_drop_database_and_contents(Oid dboid) * pgstat_gc_entry_refs(). */ bool -pgstat_drop_entry(PgStat_Kind kind, Oid dboid, uint64 objid) +pgstat_drop_entry(PgStat_Kind kind, Oid dboid, uint64 objid, RelFileNumber relfile) { PgStat_HashKey key; PgStatShared_HashEntry *shent; @@ -925,6 +927,7 @@ pgstat_drop_entry(PgStat_Kind kind, Oid dboid, uint64 objid) key.kind = kind; key.dboid = dboid; key.objid = objid; + key.relfile = relfile; /* delete local reference */ if (pgStatEntryRefHash) @@ -995,13 +998,13 @@ shared_stat_reset_contents(PgStat_Kind kind, PgStatShared_Common *header, * Reset one variable-numbered stats entry. */ void -pgstat_reset_entry(PgStat_Kind kind, Oid dboid, uint64 objid, TimestampTz ts) +pgstat_reset_entry(PgStat_Kind kind, Oid dboid, uint64 objid, RelFileNumber relfile, TimestampTz ts) { PgStat_EntryRef *entry_ref; Assert(!pgstat_get_kind_info(kind)->fixed_amount); - entry_ref = pgstat_get_entry_ref(kind, dboid, objid, false, NULL); + entry_ref = pgstat_get_entry_ref(kind, dboid, objid, relfile, false, NULL); if (!entry_ref || entry_ref->shared_entry->dropped) return; diff --git a/src/backend/utils/activity/pgstat_subscription.c b/src/backend/utils/activity/pgstat_subscription.c index e06c92727e..417c81246d 100644 --- a/src/backend/utils/activity/pgstat_subscription.c +++ b/src/backend/utils/activity/pgstat_subscription.c @@ -30,7 +30,7 @@ pgstat_report_subscription_error(Oid subid, bool is_apply_error) PgStat_BackendSubEntry *pending; entry_ref = pgstat_prep_pending_entry(PGSTAT_KIND_SUBSCRIPTION, - InvalidOid, subid, NULL); + InvalidOid, subid, InvalidOid, NULL); pending = entry_ref->pending; if (is_apply_error) @@ -49,7 +49,7 @@ pgstat_report_subscription_conflict(Oid subid, ConflictType type) PgStat_BackendSubEntry *pending; entry_ref = pgstat_prep_pending_entry(PGSTAT_KIND_SUBSCRIPTION, - InvalidOid, subid, NULL); + InvalidOid, subid, InvalidOid, NULL); pending = entry_ref->pending; pending->conflict_count[type]++; } @@ -62,12 +62,12 @@ pgstat_create_subscription(Oid subid) { /* Ensures that stats are dropped if transaction rolls back */ pgstat_create_transactional(PGSTAT_KIND_SUBSCRIPTION, - InvalidOid, subid); + InvalidOid, subid, InvalidOid); /* Create and initialize the subscription stats entry */ - pgstat_get_entry_ref(PGSTAT_KIND_SUBSCRIPTION, InvalidOid, subid, + pgstat_get_entry_ref(PGSTAT_KIND_SUBSCRIPTION, InvalidOid, subid, InvalidOid, true, NULL); - pgstat_reset_entry(PGSTAT_KIND_SUBSCRIPTION, InvalidOid, subid, 0); + pgstat_reset_entry(PGSTAT_KIND_SUBSCRIPTION, InvalidOid, subid, InvalidOid, 0); } /* @@ -79,7 +79,7 @@ void pgstat_drop_subscription(Oid subid) { pgstat_drop_transactional(PGSTAT_KIND_SUBSCRIPTION, - InvalidOid, subid); + InvalidOid, subid, InvalidOid); } /* @@ -90,7 +90,7 @@ PgStat_StatSubEntry * pgstat_fetch_stat_subscription(Oid subid) { return (PgStat_StatSubEntry *) - pgstat_fetch_entry(PGSTAT_KIND_SUBSCRIPTION, InvalidOid, subid); + pgstat_fetch_entry(PGSTAT_KIND_SUBSCRIPTION, InvalidOid, subid, InvalidOid); } /* diff --git a/src/backend/utils/activity/pgstat_xact.c b/src/backend/utils/activity/pgstat_xact.c index f87a195996..4c663b7d62 100644 --- a/src/backend/utils/activity/pgstat_xact.c +++ b/src/backend/utils/activity/pgstat_xact.c @@ -30,7 +30,7 @@ static void AtEOXact_PgStat_DroppedStats(PgStat_SubXactStatus *xact_state, bool static void AtEOSubXact_PgStat_DroppedStats(PgStat_SubXactStatus *xact_state, bool isCommit, int nestDepth); -static PgStat_SubXactStatus *pgStatXactStack = NULL; +PgStat_SubXactStatus *pgStatXactStack = NULL; /* @@ -85,7 +85,7 @@ AtEOXact_PgStat_DroppedStats(PgStat_SubXactStatus *xact_state, bool isCommit) * Transaction that dropped an object committed. Drop the stats * too. */ - if (!pgstat_drop_entry(it->kind, it->dboid, objid)) + if (!pgstat_drop_entry(it->kind, it->dboid, objid, it->relfile)) not_freed_count++; } else if (!isCommit && pending->is_create) @@ -94,7 +94,7 @@ AtEOXact_PgStat_DroppedStats(PgStat_SubXactStatus *xact_state, bool isCommit) * Transaction that created an object aborted. Drop the stats * associated with the object. */ - if (!pgstat_drop_entry(it->kind, it->dboid, objid)) + if (!pgstat_drop_entry(it->kind, it->dboid, objid, it->relfile)) not_freed_count++; } @@ -106,6 +106,38 @@ AtEOXact_PgStat_DroppedStats(PgStat_SubXactStatus *xact_state, bool isCommit) pgstat_request_entry_refs_gc(); } +/* + * Remove a relfilenode stat from the list of stats to be dropped. + */ +void +PgStat_RemoveRelFileNodeFromDroppedStats(PgStat_SubXactStatus *xact_state, RelFileLocator rlocator) +{ + dlist_mutable_iter iter; + + if (dclist_count(&xact_state->pending_drops) == 0) + return; + + dclist_foreach_modify(iter, &xact_state->pending_drops) + { + PgStat_PendingDroppedStatsItem *pending = + dclist_container(PgStat_PendingDroppedStatsItem, node, iter.cur); + xl_xact_stats_item *it = &pending->item; + + if (it->kind == PGSTAT_KIND_RELFILENODE && it->dboid == rlocator.dbOid + && it->relfile == rlocator.relNumber) + { + uint64 objid = ((uint64) it->objid_hi) << 32 | it->objid_lo; + + if (objid == rlocator.spcOid) + { + dclist_delete_from(&xact_state->pending_drops, &pending->node); + pfree(pending); + return; + } + } + } +} + /* * Called from access/transam/xact.c at subtransaction commit/abort. */ @@ -160,7 +192,7 @@ AtEOSubXact_PgStat_DroppedStats(PgStat_SubXactStatus *xact_state, * Subtransaction creating a new stats object aborted. Drop the * stats object. */ - if (!pgstat_drop_entry(it->kind, it->dboid, objid)) + if (!pgstat_drop_entry(it->kind, it->dboid, objid, it->relfile)) not_freed_count++; pfree(pending); } @@ -323,7 +355,11 @@ pgstat_execute_transactional_drops(int ndrops, struct xl_xact_stats_item *items, xl_xact_stats_item *it = &items[i]; uint64 objid = ((uint64) it->objid_hi) << 32 | it->objid_lo; - if (!pgstat_drop_entry(it->kind, it->dboid, objid)) + /* leave it to pgstat_drop_transactional() in RelationDropStorage() */ + if (it->kind == PGSTAT_KIND_RELFILENODE) + continue; + + if (!pgstat_drop_entry(it->kind, it->dboid, objid, it->relfile)) not_freed_count++; } @@ -332,7 +368,8 @@ pgstat_execute_transactional_drops(int ndrops, struct xl_xact_stats_item *items, } static void -create_drop_transactional_internal(PgStat_Kind kind, Oid dboid, uint64 objid, bool is_create) +create_drop_transactional_internal(PgStat_Kind kind, Oid dboid, uint64 objid, + RelFileNumber relfile, bool is_create) { int nest_level = GetCurrentTransactionNestLevel(); PgStat_SubXactStatus *xact_state; @@ -346,6 +383,7 @@ create_drop_transactional_internal(PgStat_Kind kind, Oid dboid, uint64 objid, bo drop->item.dboid = dboid; drop->item.objid_lo = (uint32) objid; drop->item.objid_hi = (uint32) (objid >> 32); + drop->item.relfile = relfile; dclist_push_tail(&xact_state->pending_drops, &drop->node); } @@ -358,19 +396,19 @@ create_drop_transactional_internal(PgStat_Kind kind, Oid dboid, uint64 objid, bo * dropped. */ void -pgstat_create_transactional(PgStat_Kind kind, Oid dboid, uint64 objid) +pgstat_create_transactional(PgStat_Kind kind, Oid dboid, uint64 objid, RelFileNumber relfile) { - if (pgstat_get_entry_ref(kind, dboid, objid, false, NULL)) + if (pgstat_get_entry_ref(kind, dboid, objid, relfile, false, NULL)) { ereport(WARNING, - errmsg("resetting existing statistics for kind %s, db=%u, oid=%llu", + errmsg("resetting existing statistics for kind %s, db=%u, oid=%llu, relfile=%u", (pgstat_get_kind_info(kind))->name, dboid, - (unsigned long long) objid)); + (unsigned long long) objid, relfile)); - pgstat_reset(kind, dboid, objid); + pgstat_reset(kind, dboid, objid, relfile); } - create_drop_transactional_internal(kind, dboid, objid, /* create */ true); + create_drop_transactional_internal(kind, dboid, objid, relfile, /* create */ true); } /* @@ -381,7 +419,7 @@ pgstat_create_transactional(PgStat_Kind kind, Oid dboid, uint64 objid) * alive. */ void -pgstat_drop_transactional(PgStat_Kind kind, Oid dboid, uint64 objid) +pgstat_drop_transactional(PgStat_Kind kind, Oid dboid, uint64 objid, RelFileNumber relfile) { - create_drop_transactional_internal(kind, dboid, objid, /* create */ false); + create_drop_transactional_internal(kind, dboid, objid, relfile, /* create */ false); } diff --git a/src/backend/utils/adt/pgstatfuncs.c b/src/backend/utils/adt/pgstatfuncs.c index f7b50e0b5a..d7cc55c993 100644 --- a/src/backend/utils/adt/pgstatfuncs.c +++ b/src/backend/utils/adt/pgstatfuncs.c @@ -106,6 +106,30 @@ PG_STAT_GET_RELENTRY_INT64(tuples_updated) /* pg_stat_get_vacuum_count */ PG_STAT_GET_RELENTRY_INT64(vacuum_count) +#define PG_STAT_GET_RELFILEENTRY_INT64(stat) \ +Datum \ +CppConcat(pg_stat_get_relfilenode_,stat)(PG_FUNCTION_ARGS) \ +{ \ + Oid dboid = PG_GETARG_OID(0); \ + Oid spcOid = PG_GETARG_OID(1); \ + RelFileNumber relfile = PG_GETARG_OID(2); \ + int64 result; \ + PgStat_StatRelFileNodeEntry *relfileentry; \ + \ + if ((relfileentry = pgstat_fetch_stat_relfilenodeentry(dboid, spcOid, relfile)) == NULL) \ + result = 0; \ + else \ + result = (int64) (relfileentry->stat); \ + \ + PG_RETURN_INT64(result); \ +} + +/* pg_stat_get_relfilenode_blocks_written */ +PG_STAT_GET_RELFILEENTRY_INT64(blocks_written) + +/* pg_stat_get_blocks_written */ +PG_STAT_GET_RELENTRY_INT64(blocks_written) + #define PG_STAT_GET_RELENTRY_TIMESTAMPTZ(stat) \ Datum \ CppConcat(pg_stat_get_,stat)(PG_FUNCTION_ARGS) \ @@ -1764,7 +1788,7 @@ pg_stat_reset_single_table_counters(PG_FUNCTION_ARGS) Oid taboid = PG_GETARG_OID(0); Oid dboid = (IsSharedRelation(taboid) ? InvalidOid : MyDatabaseId); - pgstat_reset(PGSTAT_KIND_RELATION, dboid, taboid); + pgstat_reset(PGSTAT_KIND_RELATION, dboid, taboid, InvalidOid); PG_RETURN_VOID(); } @@ -1774,7 +1798,7 @@ pg_stat_reset_single_function_counters(PG_FUNCTION_ARGS) { Oid funcoid = PG_GETARG_OID(0); - pgstat_reset(PGSTAT_KIND_FUNCTION, MyDatabaseId, funcoid); + pgstat_reset(PGSTAT_KIND_FUNCTION, MyDatabaseId, funcoid, InvalidOid); PG_RETURN_VOID(); } @@ -1832,7 +1856,7 @@ pg_stat_reset_subscription_stats(PG_FUNCTION_ARGS) ereport(ERROR, (errcode(ERRCODE_INVALID_PARAMETER_VALUE), errmsg("invalid subscription OID %u", subid))); - pgstat_reset(PGSTAT_KIND_SUBSCRIPTION, InvalidOid, subid); + pgstat_reset(PGSTAT_KIND_SUBSCRIPTION, InvalidOid, subid, InvalidOid); } PG_RETURN_VOID(); @@ -2059,7 +2083,9 @@ pg_stat_have_stats(PG_FUNCTION_ARGS) char *stats_type = text_to_cstring(PG_GETARG_TEXT_P(0)); Oid dboid = PG_GETARG_OID(1); uint64 objid = PG_GETARG_INT64(2); + Oid relfile = PG_GETARG_OID(3); + PgStat_Kind kind = pgstat_get_kind_from_str(stats_type); - PG_RETURN_BOOL(pgstat_have_entry(kind, dboid, objid)); + PG_RETURN_BOOL(pgstat_have_entry(kind, dboid, objid, relfile)); } diff --git a/src/include/access/tableam.h b/src/include/access/tableam.h index adb478a93c..a3ae8465dd 100644 --- a/src/include/access/tableam.h +++ b/src/include/access/tableam.h @@ -21,7 +21,9 @@ #include "access/sdir.h" #include "access/xact.h" #include "executor/tuptable.h" +#include "pgstat.h" #include "storage/read_stream.h" +#include "utils/pgstat_internal.h" #include "utils/rel.h" #include "utils/snapshot.h" @@ -1633,6 +1635,23 @@ table_relation_set_new_filelocator(Relation rel, TransactionId *freezeXid, MultiXactId *minmulti) { + PgStat_StatRelFileNodeEntry *relfileentry; + PgStat_StatTabEntry *tabentry = NULL; + PgStat_EntryRef *entry_ref = NULL; + PgStatShared_Relation *shtabentry; + + entry_ref = pgstat_get_entry_ref(PGSTAT_KIND_RELATION, MyDatabaseId, rel->rd_id, InvalidOid, false, NULL); + if (entry_ref) + { + shtabentry = (PgStatShared_Relation *) entry_ref->shared_stats; + tabentry = &shtabentry->stats; + } + + relfileentry = pgstat_fetch_stat_relfilenodeentry(rel->rd_locator.dbOid, rel->rd_locator.spcOid, rel->rd_locator.relNumber); + + if (tabentry && relfileentry) + tabentry->blocks_written += relfileentry->blocks_written; + rel->rd_tableam->relation_set_new_filelocator(rel, newrlocator, persistence, freezeXid, minmulti); diff --git a/src/include/access/xact.h b/src/include/access/xact.h index fb64d7413a..7a50e5d008 100644 --- a/src/include/access/xact.h +++ b/src/include/access/xact.h @@ -290,6 +290,7 @@ typedef struct xl_xact_stats_item */ uint32 objid_lo; uint32 objid_hi; + RelFileNumber relfile; } xl_xact_stats_item; typedef struct xl_xact_stats_items diff --git a/src/include/catalog/pg_proc.dat b/src/include/catalog/pg_proc.dat index a38e20f5d9..2a565366cd 100644 --- a/src/include/catalog/pg_proc.dat +++ b/src/include/catalog/pg_proc.dat @@ -5469,6 +5469,14 @@ proname => 'pg_stat_get_tuples_updated', provolatile => 's', proparallel => 'r', prorettype => 'int8', proargtypes => 'oid', prosrc => 'pg_stat_get_tuples_updated' }, +{ oid => '9280', descr => 'statistics: number of blocks written', + proname => 'pg_stat_get_relfilenode_blocks_written', provolatile => 's', + proparallel => 'r', + proargtypes => 'oid oid oid', + prorettype => 'int8', + proallargtypes => '{oid,oid,oid,int8}', + proargmodes => '{i,i,i,o}', + prosrc => 'pg_stat_get_relfilenode_blocks_written' }, { oid => '1933', descr => 'statistics: number of tuples deleted', proname => 'pg_stat_get_tuples_deleted', provolatile => 's', proparallel => 'r', prorettype => 'int8', proargtypes => 'oid', @@ -5508,6 +5516,10 @@ proname => 'pg_stat_get_blocks_hit', provolatile => 's', proparallel => 'r', prorettype => 'int8', proargtypes => 'oid', prosrc => 'pg_stat_get_blocks_hit' }, +{ oid => '8438', descr => 'statistics: number of blocks written', + proname => 'pg_stat_get_blocks_written', provolatile => 's', proparallel => 'r', + prorettype => 'int8', proargtypes => 'oid', + prosrc => 'pg_stat_get_blocks_written' }, { oid => '2781', descr => 'statistics: last manual vacuum time for a table', proname => 'pg_stat_get_last_vacuum_time', provolatile => 's', proparallel => 'r', prorettype => 'timestamptz', proargtypes => 'oid', @@ -5594,7 +5606,7 @@ { oid => '6230', descr => 'statistics: check if a stats object exists', proname => 'pg_stat_have_stats', provolatile => 'v', proparallel => 'r', - prorettype => 'bool', proargtypes => 'text oid int8', + prorettype => 'bool', proargtypes => 'text oid int8 oid', prosrc => 'pg_stat_have_stats' }, { oid => '6231', descr => 'statistics: information about subscription stats', diff --git a/src/include/pgstat.h b/src/include/pgstat.h index df53fa2d4f..ecfbb7cace 100644 --- a/src/include/pgstat.h +++ b/src/include/pgstat.h @@ -16,6 +16,7 @@ #include "portability/instr_time.h" #include "postmaster/pgarch.h" /* for MAX_XFN_CHARS */ #include "replication/conflict.h" +#include "storage/relfilelocator.h" #include "utils/backend_progress.h" /* for backward compatibility */ #include "utils/backend_status.h" /* for backward compatibility */ #include "utils/relcache.h" @@ -46,17 +47,18 @@ /* stats for variable-numbered objects */ #define PGSTAT_KIND_DATABASE 1 /* database-wide statistics */ #define PGSTAT_KIND_RELATION 2 /* per-table statistics */ -#define PGSTAT_KIND_FUNCTION 3 /* per-function statistics */ -#define PGSTAT_KIND_REPLSLOT 4 /* per-slot statistics */ -#define PGSTAT_KIND_SUBSCRIPTION 5 /* per-subscription statistics */ +#define PGSTAT_KIND_RELFILENODE 3 /* per-relfilenode statistics */ +#define PGSTAT_KIND_FUNCTION 4 /* per-function statistics */ +#define PGSTAT_KIND_REPLSLOT 5 /* per-slot statistics */ +#define PGSTAT_KIND_SUBSCRIPTION 6 /* per-subscription statistics */ /* stats for fixed-numbered objects */ -#define PGSTAT_KIND_ARCHIVER 6 -#define PGSTAT_KIND_BGWRITER 7 -#define PGSTAT_KIND_CHECKPOINTER 8 -#define PGSTAT_KIND_IO 9 -#define PGSTAT_KIND_SLRU 10 -#define PGSTAT_KIND_WAL 11 +#define PGSTAT_KIND_ARCHIVER 7 +#define PGSTAT_KIND_BGWRITER 8 +#define PGSTAT_KIND_CHECKPOINTER 9 +#define PGSTAT_KIND_IO 10 +#define PGSTAT_KIND_SLRU 11 +#define PGSTAT_KIND_WAL 12 #define PGSTAT_KIND_BUILTIN_MIN PGSTAT_KIND_DATABASE #define PGSTAT_KIND_BUILTIN_MAX PGSTAT_KIND_WAL @@ -452,6 +454,7 @@ typedef struct PgStat_StatTabEntry PgStat_Counter blocks_fetched; PgStat_Counter blocks_hit; + PgStat_Counter blocks_written; TimestampTz last_vacuum_time; /* user initiated vacuum */ PgStat_Counter vacuum_count; @@ -463,6 +466,13 @@ typedef struct PgStat_StatTabEntry PgStat_Counter autoanalyze_count; } PgStat_StatTabEntry; +typedef struct PgStat_StatRelFileNodeEntry +{ + PgStat_Counter blocks_fetched; + PgStat_Counter blocks_hit; + PgStat_Counter blocks_written; +} PgStat_StatRelFileNodeEntry; + typedef struct PgStat_WalStats { PgStat_Counter wal_records; @@ -513,7 +523,7 @@ extern long pgstat_report_stat(bool force); extern void pgstat_force_next_flush(void); extern void pgstat_reset_counters(void); -extern void pgstat_reset(PgStat_Kind kind, Oid dboid, uint64 objid); +extern void pgstat_reset(PgStat_Kind kind, Oid dboid, uint64 objid, RelFileNumber relfile); extern void pgstat_reset_of_kind(PgStat_Kind kind); /* stats accessors */ @@ -522,7 +532,7 @@ extern TimestampTz pgstat_get_stat_snapshot_timestamp(bool *have_snapshot); /* helpers */ extern PgStat_Kind pgstat_get_kind_from_str(char *kind_str); -extern bool pgstat_have_entry(PgStat_Kind kind, Oid dboid, uint64 objid); +extern bool pgstat_have_entry(PgStat_Kind kind, Oid dboid, uint64 objid, RelFileNumber relfile); /* @@ -631,6 +641,10 @@ extern void pgstat_report_analyze(Relation rel, PgStat_Counter livetuples, PgStat_Counter deadtuples, bool resetcounter); +extern void pgstat_report_relfilenode_blks_written(RelFileLocator locator); +extern void pgstat_report_relfilenode_buffer_read(Relation reln); +extern void pgstat_report_relfilenode_buffer_hit(Relation reln); + /* * If stats are enabled, but pending data hasn't been prepared yet, call * pgstat_assoc_relation() to do so. See its comment for why this is done @@ -690,6 +704,7 @@ extern void pgstat_twophase_postabort(TransactionId xid, uint16 info, void *recdata, uint32 len); extern PgStat_StatTabEntry *pgstat_fetch_stat_tabentry(Oid relid); +extern PgStat_StatRelFileNodeEntry *pgstat_fetch_stat_relfilenodeentry(Oid dboid, Oid spcOid, RelFileNumber relfile); extern PgStat_StatTabEntry *pgstat_fetch_stat_tabentry_ext(bool shared, Oid reloid); extern PgStat_TableStatus *find_tabstat_entry(Oid rel_id); diff --git a/src/include/utils/pgstat_internal.h b/src/include/utils/pgstat_internal.h index 61b2e1f96b..b5700708fc 100644 --- a/src/include/utils/pgstat_internal.h +++ b/src/include/utils/pgstat_internal.h @@ -55,6 +55,7 @@ typedef struct PgStat_HashKey Oid dboid; /* database ID. InvalidOid for shared objects. */ uint64 objid; /* object ID (table, function, etc.), or * identifier. */ + RelFileNumber relfile; /* relfilenumber for RelFileLocator. */ } PgStat_HashKey; /* @@ -410,6 +411,12 @@ typedef struct PgStatShared_Relation PgStat_StatTabEntry stats; } PgStatShared_Relation; +typedef struct PgStatShared_RelFileNode +{ + PgStatShared_Common header; + PgStat_StatRelFileNodeEntry stats; +} PgStatShared_RelFileNode; + typedef struct PgStatShared_Function { PgStatShared_Common header; @@ -548,6 +555,9 @@ static inline void *pgstat_get_entry_data(PgStat_Kind kind, PgStatShared_Common static inline void *pgstat_get_custom_shmem_data(PgStat_Kind kind); static inline void *pgstat_get_custom_snapshot_data(PgStat_Kind kind); +extern PgStat_SubXactStatus *pgStatXactStack; +extern void PgStat_RemoveRelFileNodeFromDroppedStats(PgStat_SubXactStatus *xact_state, RelFileLocator rlocator); + /* * Functions in pgstat.c @@ -565,12 +575,14 @@ extern void pgstat_assert_is_up(void); extern void pgstat_delete_pending_entry(PgStat_EntryRef *entry_ref); extern PgStat_EntryRef *pgstat_prep_pending_entry(PgStat_Kind kind, Oid dboid, - uint64 objid, + uint64 objid, RelFileNumber relfile, bool *created_entry); extern PgStat_EntryRef *pgstat_fetch_pending_entry(PgStat_Kind kind, - Oid dboid, uint64 objid); + Oid dboid, uint64 objid, + RelFileNumber relfile); -extern void *pgstat_fetch_entry(PgStat_Kind kind, Oid dboid, uint64 objid); +extern void *pgstat_fetch_entry(PgStat_Kind kind, Oid dboid, uint64 objid, + RelFileNumber relfile); extern void pgstat_snapshot_fixed(PgStat_Kind kind); @@ -645,6 +657,7 @@ extern void AtPrepare_PgStat_Relations(PgStat_SubXactStatus *xact_state); extern void PostPrepare_PgStat_Relations(PgStat_SubXactStatus *xact_state); extern bool pgstat_relation_flush_cb(PgStat_EntryRef *entry_ref, bool nowait); +extern bool pgstat_relfilenode_flush_cb(PgStat_EntryRef *entry_ref, bool nowait); extern void pgstat_relation_delete_pending_cb(PgStat_EntryRef *entry_ref); @@ -665,15 +678,17 @@ extern void pgstat_attach_shmem(void); extern void pgstat_detach_shmem(void); extern PgStat_EntryRef *pgstat_get_entry_ref(PgStat_Kind kind, Oid dboid, uint64 objid, - bool create, bool *created_entry); + RelFileNumber relfile, bool create, + bool *created_entry); extern bool pgstat_lock_entry(PgStat_EntryRef *entry_ref, bool nowait); extern bool pgstat_lock_entry_shared(PgStat_EntryRef *entry_ref, bool nowait); extern void pgstat_unlock_entry(PgStat_EntryRef *entry_ref); -extern bool pgstat_drop_entry(PgStat_Kind kind, Oid dboid, uint64 objid); +extern bool pgstat_drop_entry(PgStat_Kind kind, Oid dboid, uint64 objid, RelFileNumber relfile); extern void pgstat_drop_all_entries(void); extern PgStat_EntryRef *pgstat_get_entry_ref_locked(PgStat_Kind kind, Oid dboid, uint64 objid, - bool nowait); -extern void pgstat_reset_entry(PgStat_Kind kind, Oid dboid, uint64 objid, TimestampTz ts); + RelFileNumber relfile, bool nowait); +extern void pgstat_reset_entry(PgStat_Kind kind, Oid dboid, uint64 objid, + RelFileNumber relfile, TimestampTz ts); extern void pgstat_reset_entries_of_kind(PgStat_Kind kind, TimestampTz ts); extern void pgstat_reset_matching_entries(bool (*do_reset) (PgStatShared_HashEntry *, Datum), Datum match_data, @@ -722,8 +737,8 @@ extern void pgstat_subscription_reset_timestamp_cb(PgStatShared_Common *header, */ extern PgStat_SubXactStatus *pgstat_get_xact_stack_level(int nest_level); -extern void pgstat_drop_transactional(PgStat_Kind kind, Oid dboid, uint64 objid); -extern void pgstat_create_transactional(PgStat_Kind kind, Oid dboid, uint64 objid); +extern void pgstat_drop_transactional(PgStat_Kind kind, Oid dboid, uint64 objid, RelFileNumber relfile); +extern void pgstat_create_transactional(PgStat_Kind kind, Oid dboid, uint64 objid, RelFileNumber relfile); /* diff --git a/src/test/recovery/t/029_stats_restart.pl b/src/test/recovery/t/029_stats_restart.pl index d14ac12418..4c83a0c167 100644 --- a/src/test/recovery/t/029_stats_restart.pl +++ b/src/test/recovery/t/029_stats_restart.pl @@ -40,10 +40,10 @@ trigger_funcrel_stat(); # verify stats objects exist my $sect = "initial"; -is(have_stats('database', $dboid, 0), 't', "$sect: db stats do exist"); -is(have_stats('function', $dboid, $funcoid), +is(have_stats('database', $dboid, 0, 0), 't', "$sect: db stats do exist"); +is(have_stats('function', $dboid, $funcoid, 0), 't', "$sect: function stats do exist"); -is(have_stats('relation', $dboid, $tableoid), +is(have_stats('relation', $dboid, $tableoid, 0), 't', "$sect: relation stats do exist"); # regular shutdown @@ -64,10 +64,10 @@ copy($og_stats, $statsfile) or die "Copy failed: $!"; $node->start; $sect = "copy"; -is(have_stats('database', $dboid, 0), 't', "$sect: db stats do exist"); -is(have_stats('function', $dboid, $funcoid), +is(have_stats('database', $dboid, 0, 0), 't', "$sect: db stats do exist"); +is(have_stats('function', $dboid, $funcoid, 0), 't', "$sect: function stats do exist"); -is(have_stats('relation', $dboid, $tableoid), +is(have_stats('relation', $dboid, $tableoid, 0), 't', "$sect: relation stats do exist"); $node->stop('immediate'); @@ -81,10 +81,10 @@ $node->start; # stats should have been discarded $sect = "post immediate"; -is(have_stats('database', $dboid, 0), 'f', "$sect: db stats do not exist"); -is(have_stats('function', $dboid, $funcoid), +is(have_stats('database', $dboid, 0, 0), 'f', "$sect: db stats do not exist"); +is(have_stats('function', $dboid, $funcoid, 0), 'f', "$sect: function stats do exist"); -is(have_stats('relation', $dboid, $tableoid), +is(have_stats('relation', $dboid, $tableoid, 0), 'f', "$sect: relation stats do not exist"); # get rid of backup statsfile @@ -95,10 +95,10 @@ unlink $statsfile or die "cannot unlink $statsfile $!"; trigger_funcrel_stat(); $sect = "post immediate, new"; -is(have_stats('database', $dboid, 0), 't', "$sect: db stats do exist"); -is(have_stats('function', $dboid, $funcoid), +is(have_stats('database', $dboid, 0, 0), 't', "$sect: db stats do exist"); +is(have_stats('function', $dboid, $funcoid, 0), 't', "$sect: function stats do exist"); -is(have_stats('relation', $dboid, $tableoid), +is(have_stats('relation', $dboid, $tableoid, 0), 't', "$sect: relation stats do exist"); # regular shutdown @@ -114,10 +114,10 @@ $node->start; # no stats present due to invalid stats file $sect = "invalid_overwrite"; -is(have_stats('database', $dboid, 0), 'f', "$sect: db stats do not exist"); -is(have_stats('function', $dboid, $funcoid), +is(have_stats('database', $dboid, 0, 0), 'f', "$sect: db stats do not exist"); +is(have_stats('function', $dboid, $funcoid, 0), 'f', "$sect: function stats do not exist"); -is(have_stats('relation', $dboid, $tableoid), +is(have_stats('relation', $dboid, $tableoid, 0), 'f', "$sect: relation stats do not exist"); @@ -130,10 +130,10 @@ append_file($og_stats, "XYZ"); $node->start; $sect = "invalid_append"; -is(have_stats('database', $dboid, 0), 'f', "$sect: db stats do not exist"); -is(have_stats('function', $dboid, $funcoid), +is(have_stats('database', $dboid, 0, 0), 'f', "$sect: db stats do not exist"); +is(have_stats('function', $dboid, $funcoid, 0), 'f', "$sect: function stats do not exist"); -is(have_stats('relation', $dboid, $tableoid), +is(have_stats('relation', $dboid, $tableoid, 0), 'f', "$sect: relation stats do not exist"); @@ -292,10 +292,10 @@ sub trigger_funcrel_stat sub have_stats { - my ($kind, $dboid, $objid) = @_; + my ($kind, $dboid, $objid, $relfile) = @_; return $node->safe_psql($connect_db, - "SELECT pg_stat_have_stats('$kind', $dboid, $objid)"); + "SELECT pg_stat_have_stats('$kind', $dboid, $objid, $relfile)"); } sub overwrite_file diff --git a/src/test/recovery/t/030_stats_cleanup_replica.pl b/src/test/recovery/t/030_stats_cleanup_replica.pl index 74b516cc7c..317df24c4f 100644 --- a/src/test/recovery/t/030_stats_cleanup_replica.pl +++ b/src/test/recovery/t/030_stats_cleanup_replica.pl @@ -179,9 +179,9 @@ sub test_standby_func_tab_stats_status my %stats; $stats{rel} = $node_standby->safe_psql($connect_db, - "SELECT pg_stat_have_stats('relation', $dboid, $tableoid)"); + "SELECT pg_stat_have_stats('relation', $dboid, $tableoid, 0)"); $stats{func} = $node_standby->safe_psql($connect_db, - "SELECT pg_stat_have_stats('function', $dboid, $funcoid)"); + "SELECT pg_stat_have_stats('function', $dboid, $funcoid, 0)"); is_deeply(\%stats, \%expected, "$sect: standby stats as expected"); @@ -194,7 +194,7 @@ sub test_standby_db_stats_status my ($connect_db, $dboid, $present) = @_; is( $node_standby->safe_psql( - $connect_db, "SELECT pg_stat_have_stats('database', $dboid, 0)"), + $connect_db, "SELECT pg_stat_have_stats('database', $dboid, 0, 0)"), $present, "$sect: standby db stats as expected"); } diff --git a/src/test/regress/expected/rules.out b/src/test/regress/expected/rules.out index 2b47013f11..e2010a7dc4 100644 --- a/src/test/regress/expected/rules.out +++ b/src/test/regress/expected/rules.out @@ -2342,6 +2342,11 @@ pg_statio_all_tables| SELECT c.oid AS relid, n.nspname AS schemaname, c.relname, (pg_stat_get_blocks_fetched(c.oid) - pg_stat_get_blocks_hit(c.oid)) AS heap_blks_read, + (pg_stat_get_blocks_written(c.oid) + pg_stat_get_relfilenode_blocks_written(d.oid, + CASE + WHEN (c.reltablespace <> (0)::oid) THEN c.reltablespace + ELSE d.dattablespace + END, c.relfilenode)) AS heap_blks_written, pg_stat_get_blocks_hit(c.oid) AS heap_blks_hit, i.idx_blks_read, i.idx_blks_hit, @@ -2349,7 +2354,8 @@ pg_statio_all_tables| SELECT c.oid AS relid, pg_stat_get_blocks_hit(t.oid) AS toast_blks_hit, x.idx_blks_read AS tidx_blks_read, x.idx_blks_hit AS tidx_blks_hit - FROM ((((pg_class c + FROM pg_database d, + ((((pg_class c LEFT JOIN pg_class t ON ((c.reltoastrelid = t.oid))) LEFT JOIN pg_namespace n ON ((n.oid = c.relnamespace))) LEFT JOIN LATERAL ( SELECT (sum((pg_stat_get_blocks_fetched(pg_index.indexrelid) - pg_stat_get_blocks_hit(pg_index.indexrelid))))::bigint AS idx_blks_read, @@ -2360,7 +2366,7 @@ pg_statio_all_tables| SELECT c.oid AS relid, (sum(pg_stat_get_blocks_hit(pg_index.indexrelid)))::bigint AS idx_blks_hit FROM pg_index WHERE (pg_index.indrelid = t.oid)) x ON (true)) - WHERE (c.relkind = ANY (ARRAY['r'::"char", 't'::"char", 'm'::"char"])); + WHERE ((c.relkind = ANY (ARRAY['r'::"char", 't'::"char", 'm'::"char"])) AND (d.datname = current_database())); pg_statio_sys_indexes| SELECT relid, indexrelid, schemaname, @@ -2381,6 +2387,7 @@ pg_statio_sys_tables| SELECT relid, schemaname, relname, heap_blks_read, + heap_blks_written, heap_blks_hit, idx_blks_read, idx_blks_hit, @@ -2410,6 +2417,7 @@ pg_statio_user_tables| SELECT relid, schemaname, relname, heap_blks_read, + heap_blks_written, heap_blks_hit, idx_blks_read, idx_blks_hit, diff --git a/src/test/regress/expected/stats.out b/src/test/regress/expected/stats.out index 56771f83ed..6ae4c13376 100644 --- a/src/test/regress/expected/stats.out +++ b/src/test/regress/expected/stats.out @@ -1111,23 +1111,23 @@ ROLLBACK; -- pg_stat_have_stats behavior ---- -- fixed-numbered stats exist -SELECT pg_stat_have_stats('bgwriter', 0, 0); +SELECT pg_stat_have_stats('bgwriter', 0, 0, 0); pg_stat_have_stats -------------------- t (1 row) -- unknown stats kinds error out -SELECT pg_stat_have_stats('zaphod', 0, 0); +SELECT pg_stat_have_stats('zaphod', 0, 0, 0); ERROR: invalid statistics kind: "zaphod" -- db stats have objid 0 -SELECT pg_stat_have_stats('database', :dboid, 1); +SELECT pg_stat_have_stats('database', :dboid, 1, 0); pg_stat_have_stats -------------------- f (1 row) -SELECT pg_stat_have_stats('database', :dboid, 0); +SELECT pg_stat_have_stats('database', :dboid, 0, 0); pg_stat_have_stats -------------------- t @@ -1144,21 +1144,21 @@ select a from stats_test_tab1 where a = 3; 3 (1 row) -SELECT pg_stat_have_stats('relation', :dboid, :stats_test_idx1_oid); +SELECT pg_stat_have_stats('relation', :dboid, :stats_test_idx1_oid, 0); pg_stat_have_stats -------------------- t (1 row) -- pg_stat_have_stats returns false for dropped index with stats -SELECT pg_stat_have_stats('relation', :dboid, :stats_test_idx1_oid); +SELECT pg_stat_have_stats('relation', :dboid, :stats_test_idx1_oid, 0); pg_stat_have_stats -------------------- t (1 row) DROP index stats_test_idx1; -SELECT pg_stat_have_stats('relation', :dboid, :stats_test_idx1_oid); +SELECT pg_stat_have_stats('relation', :dboid, :stats_test_idx1_oid, 0); pg_stat_have_stats -------------------- f @@ -1174,14 +1174,14 @@ select a from stats_test_tab1 where a = 3; 3 (1 row) -SELECT pg_stat_have_stats('relation', :dboid, :stats_test_idx1_oid); +SELECT pg_stat_have_stats('relation', :dboid, :stats_test_idx1_oid, 0); pg_stat_have_stats -------------------- t (1 row) ROLLBACK; -SELECT pg_stat_have_stats('relation', :dboid, :stats_test_idx1_oid); +SELECT pg_stat_have_stats('relation', :dboid, :stats_test_idx1_oid, 0); pg_stat_have_stats -------------------- f @@ -1196,7 +1196,7 @@ select a from stats_test_tab1 where a = 3; 3 (1 row) -SELECT pg_stat_have_stats('relation', :dboid, :stats_test_idx1_oid); +SELECT pg_stat_have_stats('relation', :dboid, :stats_test_idx1_oid, 0); pg_stat_have_stats -------------------- t @@ -1204,7 +1204,7 @@ SELECT pg_stat_have_stats('relation', :dboid, :stats_test_idx1_oid); REINDEX index CONCURRENTLY stats_test_idx1; -- false for previous oid -SELECT pg_stat_have_stats('relation', :dboid, :stats_test_idx1_oid); +SELECT pg_stat_have_stats('relation', :dboid, :stats_test_idx1_oid, 0); pg_stat_have_stats -------------------- f @@ -1212,7 +1212,7 @@ SELECT pg_stat_have_stats('relation', :dboid, :stats_test_idx1_oid); -- true for new oid SELECT 'stats_test_idx1'::regclass::oid AS stats_test_idx1_oid \gset -SELECT pg_stat_have_stats('relation', :dboid, :stats_test_idx1_oid); +SELECT pg_stat_have_stats('relation', :dboid, :stats_test_idx1_oid, 0); pg_stat_have_stats -------------------- t @@ -1220,7 +1220,7 @@ SELECT pg_stat_have_stats('relation', :dboid, :stats_test_idx1_oid); -- pg_stat_have_stats returns true for a rolled back drop index with stats BEGIN; -SELECT pg_stat_have_stats('relation', :dboid, :stats_test_idx1_oid); +SELECT pg_stat_have_stats('relation', :dboid, :stats_test_idx1_oid, 0); pg_stat_have_stats -------------------- t @@ -1228,7 +1228,7 @@ SELECT pg_stat_have_stats('relation', :dboid, :stats_test_idx1_oid); DROP index stats_test_idx1; ROLLBACK; -SELECT pg_stat_have_stats('relation', :dboid, :stats_test_idx1_oid); +SELECT pg_stat_have_stats('relation', :dboid, :stats_test_idx1_oid, 0); pg_stat_have_stats -------------------- t @@ -1513,7 +1513,7 @@ SELECT :io_sum_bulkwrite_strategy_extends_after > :io_sum_bulkwrite_strategy_ext (1 row) -- Test IO stats reset -SELECT pg_stat_have_stats('io', 0, 0); +SELECT pg_stat_have_stats('io', 0, 0, 0); pg_stat_have_stats -------------------- t diff --git a/src/test/regress/sql/stats.sql b/src/test/regress/sql/stats.sql index 7147cc2f89..a992737a34 100644 --- a/src/test/regress/sql/stats.sql +++ b/src/test/regress/sql/stats.sql @@ -539,12 +539,12 @@ ROLLBACK; -- pg_stat_have_stats behavior ---- -- fixed-numbered stats exist -SELECT pg_stat_have_stats('bgwriter', 0, 0); +SELECT pg_stat_have_stats('bgwriter', 0, 0, 0); -- unknown stats kinds error out -SELECT pg_stat_have_stats('zaphod', 0, 0); +SELECT pg_stat_have_stats('zaphod', 0, 0, 0); -- db stats have objid 0 -SELECT pg_stat_have_stats('database', :dboid, 1); -SELECT pg_stat_have_stats('database', :dboid, 0); +SELECT pg_stat_have_stats('database', :dboid, 1, 0); +SELECT pg_stat_have_stats('database', :dboid, 0, 0); -- pg_stat_have_stats returns true for committed index creation CREATE table stats_test_tab1 as select generate_series(1,10) a; @@ -552,40 +552,40 @@ CREATE index stats_test_idx1 on stats_test_tab1(a); SELECT 'stats_test_idx1'::regclass::oid AS stats_test_idx1_oid \gset SET enable_seqscan TO off; select a from stats_test_tab1 where a = 3; -SELECT pg_stat_have_stats('relation', :dboid, :stats_test_idx1_oid); +SELECT pg_stat_have_stats('relation', :dboid, :stats_test_idx1_oid, 0); -- pg_stat_have_stats returns false for dropped index with stats -SELECT pg_stat_have_stats('relation', :dboid, :stats_test_idx1_oid); +SELECT pg_stat_have_stats('relation', :dboid, :stats_test_idx1_oid, 0); DROP index stats_test_idx1; -SELECT pg_stat_have_stats('relation', :dboid, :stats_test_idx1_oid); +SELECT pg_stat_have_stats('relation', :dboid, :stats_test_idx1_oid, 0); -- pg_stat_have_stats returns false for rolled back index creation BEGIN; CREATE index stats_test_idx1 on stats_test_tab1(a); SELECT 'stats_test_idx1'::regclass::oid AS stats_test_idx1_oid \gset select a from stats_test_tab1 where a = 3; -SELECT pg_stat_have_stats('relation', :dboid, :stats_test_idx1_oid); +SELECT pg_stat_have_stats('relation', :dboid, :stats_test_idx1_oid, 0); ROLLBACK; -SELECT pg_stat_have_stats('relation', :dboid, :stats_test_idx1_oid); +SELECT pg_stat_have_stats('relation', :dboid, :stats_test_idx1_oid, 0); -- pg_stat_have_stats returns true for reindex CONCURRENTLY CREATE index stats_test_idx1 on stats_test_tab1(a); SELECT 'stats_test_idx1'::regclass::oid AS stats_test_idx1_oid \gset select a from stats_test_tab1 where a = 3; -SELECT pg_stat_have_stats('relation', :dboid, :stats_test_idx1_oid); +SELECT pg_stat_have_stats('relation', :dboid, :stats_test_idx1_oid, 0); REINDEX index CONCURRENTLY stats_test_idx1; -- false for previous oid -SELECT pg_stat_have_stats('relation', :dboid, :stats_test_idx1_oid); +SELECT pg_stat_have_stats('relation', :dboid, :stats_test_idx1_oid, 0); -- true for new oid SELECT 'stats_test_idx1'::regclass::oid AS stats_test_idx1_oid \gset -SELECT pg_stat_have_stats('relation', :dboid, :stats_test_idx1_oid); +SELECT pg_stat_have_stats('relation', :dboid, :stats_test_idx1_oid, 0); -- pg_stat_have_stats returns true for a rolled back drop index with stats BEGIN; -SELECT pg_stat_have_stats('relation', :dboid, :stats_test_idx1_oid); +SELECT pg_stat_have_stats('relation', :dboid, :stats_test_idx1_oid, 0); DROP index stats_test_idx1; ROLLBACK; -SELECT pg_stat_have_stats('relation', :dboid, :stats_test_idx1_oid); +SELECT pg_stat_have_stats('relation', :dboid, :stats_test_idx1_oid, 0); -- put enable_seqscan back to on SET enable_seqscan TO on; @@ -759,7 +759,7 @@ SELECT sum(extends) AS io_sum_bulkwrite_strategy_extends_after SELECT :io_sum_bulkwrite_strategy_extends_after > :io_sum_bulkwrite_strategy_extends_before; -- Test IO stats reset -SELECT pg_stat_have_stats('io', 0, 0); +SELECT pg_stat_have_stats('io', 0, 0, 0); SELECT sum(evictions) + sum(reuses) + sum(extends) + sum(fsyncs) + sum(reads) + sum(writes) + sum(writebacks) + sum(hits) AS io_stats_pre_reset FROM pg_stat_io \gset SELECT pg_stat_reset_shared('io'); diff --git a/src/test/subscription/t/026_stats.pl b/src/test/subscription/t/026_stats.pl index 6b6a5b0b1b..89ebf5aa2c 100644 --- a/src/test/subscription/t/026_stats.pl +++ b/src/test/subscription/t/026_stats.pl @@ -290,7 +290,7 @@ $node_subscriber->safe_psql($db, qq(DROP SUBSCRIPTION $sub1_name)); # Subscription stats for sub1 should be gone is( $node_subscriber->safe_psql( - $db, qq(SELECT pg_stat_have_stats('subscription', 0, $sub1_oid))), + $db, qq(SELECT pg_stat_have_stats('subscription', 0, $sub1_oid, 0))), qq(f), qq(Subscription stats for subscription '$sub1_name' should be removed.)); @@ -309,7 +309,7 @@ DROP SUBSCRIPTION $sub2_name; # Subscription stats for sub2 should be gone is( $node_subscriber->safe_psql( - $db, qq(SELECT pg_stat_have_stats('subscription', 0, $sub2_oid))), + $db, qq(SELECT pg_stat_have_stats('subscription', 0, $sub2_oid, 0))), qq(f), qq(Subscription stats for subscription '$sub2_name' should be removed.)); diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list index 171a7dd5d2..e0b26287f3 100644 --- a/src/tools/pgindent/typedefs.list +++ b/src/tools/pgindent/typedefs.list @@ -2127,6 +2127,7 @@ PgStatShared_InjectionPoint PgStatShared_InjectionPointFixed PgStatShared_IO PgStatShared_Relation +PgStatShared_RelFileNode PgStatShared_ReplSlot PgStatShared_SLRU PgStatShared_Subscription -- 2.34.1 --gvmrl0mfoTEJEfPE-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-04 04:47 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-04 04:47 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: --- src/backend/commands/user.c | 246 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 191 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..6d8e2fa8813 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,92 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1181,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1263,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1274,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1582,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1681,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --xlWYagR37YwJXkF8-- ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --cOHldBwZEf1OqVbN Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v2-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --Dvg/WemKV0I3T/Od Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v3-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
* [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 785+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) DropRole() and GrantRole() resolve the role name to an OID before acquiring LockSharedObject() on the role. A concurrent session that commits a DROP ROLE between the read and the lock acquisition leaves the first session acting on a stale OID. This commit fixes the races by using the same approach as RangeVarGetRelidExtended(): It encapsulates name resolution, permission checking (via a caller-supplied callback), and lock acquisition inside a retry loop driven by SharedInvalidMessageCounter. If invalidation messages arrive between name resolution and locking, indicating concurrent DDL, the function retries. The lock is kept across retries and only released if the name resolves to a different OID on the next iteration. Two callbacks are provided: - RoleNameCallbackForDropRole(): checks current/session user, superuser attribute, and ADMIN OPTION privilege before locking. This is similar to what DropRole() is currently doing before LockSharedObject(). - RoleNameCallbackForGrantRole(): calls check_role_membership_authorization() to verify the current user can grant/revoke membership. This is similar to what GrantRole() is currently doing before calling AddRoleMems()/DelRoleMems(). DropRole() and GrantRole() now call RoleNameGetOid() with appropriate lock levels. AlterRole() does not need the fix because it calls CatalogTupleUpdate() on the pg_authid tuple before AddRoleMems(), which blocks a concurrent DROP ROLE. Author: Bertrand Drouvot <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg --- src/backend/commands/user.c | 248 ++++++++++++++++++++++++++---------- src/include/commands/user.h | 9 ++ 2 files changed, 193 insertions(+), 64 deletions(-) 95.5% src/backend/commands/ 4.4% src/include/commands/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index be11c49f919..5b869e91c17 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -34,6 +34,7 @@ #include "miscadmin.h" #include "port/pg_bitutils.h" #include "storage/lmgr.h" +#include "storage/sinval.h" #include "utils/acl.h" #include "utils/builtins.h" #include "utils/catcache.h" @@ -116,6 +117,10 @@ static void plan_recursive_revoke(CatCList *memlist, bool revoke_admin_option_only, DropBehavior behavior); static void InitGrantRoleOptions(GrantRoleOptions *popt); +static void RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); +static void RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); /* Check if current user has createrole privileges */ @@ -126,6 +131,94 @@ have_createrole_privilege(void) } +/* + * RoleNameGetOid + * Given a role name, look up its OID, lock it, and return the OID. + * + * This follows the same pattern as RangeVarGetRelidExtended(): + * name resolution, permission check (via callback), and lock acquisition are + * performed inside a retry loop. If invalidation messages arrive during the + * process (indicating concurrent DDL), we retry to ensure the name still + * resolves to the same OID. + * + * The callback is invoked before locking, giving callers a chance to check + * permissions. It receives the current rolename, the resolved OID, the + * previous OID (InvalidOid on first iteration), and a caller-supplied arg. + * If the callback raises an error, the function aborts without locking. + * + * If missing_ok is true and the role does not exist, returns InvalidOid. + * Otherwise, raises an error. + */ +Oid +RoleNameGetOid(const char *rolename, LOCKMODE lockmode, bool missing_ok, + RoleNameGetOidCallback callback, void *callback_arg) +{ + uint64 inval_count; + Oid roleid; + Oid oldroleid = InvalidOid; + bool retry = false; + + for (;;) + { + /* + * Remember the current invalidation count so we can detect concurrent + * DDL after locking. + */ + inval_count = SharedInvalidMessageCounter; + + /* Look up the role name */ + roleid = get_role_oid(rolename, true); + + if (!OidIsValid(roleid)) + { + if (retry) + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + if (!missing_ok) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + return InvalidOid; + } + + /* + * Invoke caller-supplied callback before locking. This is a good + * place to check permissions: we haven't taken the lock yet, but we + * know the OID we intend to lock. If concurrent DDL changes things, + * the callback will be invoked again on the next iteration. + */ + if (callback) + callback(rolename, roleid, oldroleid, callback_arg); + + /* + * If upon retry we get back the same OID, the invalidation messages + * did not change the final answer. So we're done. + * + * If we got a different OID, we've locked the role that used to have + * this name rather than the one that does now. Release the old lock. + */ + if (retry) + { + if (roleid == oldroleid) + break; + UnlockSharedObject(AuthIdRelationId, oldroleid, 0, lockmode); + } + + /* Lock the role */ + LockSharedObject(AuthIdRelationId, roleid, 0, lockmode); + + /* If no invalidation messages were processed, we're done */ + if (inval_count == SharedInvalidMessageCounter) + break; + + /* Something may have changed, retry */ + retry = true; + oldroleid = roleid; + } + + return roleid; +} + + /* * CREATE ROLE */ @@ -1090,6 +1183,59 @@ AlterRoleSet(AlterRoleSetStmt *stmt) } +/* + * Before acquiring a role lock for DROP ROLE, check that the role is not the + * current/session user and that the caller has sufficient privileges to drop it. + */ +static void +RoleNameCallbackForDropRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + HeapTuple tuple; + Form_pg_authid roleform; + + if (roleid == GetUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetOuterUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("current user cannot be dropped"))); + if (roleid == GetSessionUserId()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("session user cannot be dropped"))); + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", rolename))); + + roleform = (Form_pg_authid) GETSTRUCT(tuple); + + /* + * For safety's sake, we allow createrole holders to drop ordinary roles + * but not superuser roles, and only if they also have ADMIN OPTION. + */ + if (roleform->rolsuper && !superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", + "SUPERUSER", "SUPERUSER"))); + if (!is_admin_of_role(GetUserId(), roleid)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to drop role"), + errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", + "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); + + ReleaseSysCache(tuple); +} + + /* * DROP ROLE */ @@ -1119,9 +1265,7 @@ DropRole(DropRoleStmt *stmt) { RoleSpec *rolspec = lfirst(item); char *role; - HeapTuple tuple, - tmp_tuple; - Form_pg_authid roleform; + HeapTuple tmp_tuple; ScanKeyData scankey; SysScanDesc sscan; Oid roleid; @@ -1132,71 +1276,27 @@ DropRole(DropRoleStmt *stmt) errmsg("cannot use special role specifier in DROP ROLE"))); role = rolspec->rolename; - tuple = SearchSysCache1(AUTHNAME, PointerGetDatum(role)); - if (!HeapTupleIsValid(tuple)) - { - if (!stmt->missing_ok) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("role \"%s\" does not exist", role))); - } - else - { - ereport(NOTICE, - (errmsg("role \"%s\" does not exist, skipping", - role))); - } + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP or ALTER commits between name + * resolution and lock acquisition. + */ + roleid = RoleNameGetOid(role, AccessExclusiveLock, stmt->missing_ok, + RoleNameCallbackForDropRole, NULL); + if (!OidIsValid(roleid)) + { + /* missing_ok case: role doesn't exist */ + ereport(NOTICE, + (errmsg("role \"%s\" does not exist, skipping", + role))); continue; } - roleform = (Form_pg_authid) GETSTRUCT(tuple); - roleid = roleform->oid; - - if (roleid == GetUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetOuterUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("current user cannot be dropped"))); - if (roleid == GetSessionUserId()) - ereport(ERROR, - (errcode(ERRCODE_OBJECT_IN_USE), - errmsg("session user cannot be dropped"))); - - /* - * For safety's sake, we allow createrole holders to drop ordinary - * roles but not superuser roles, and only if they also have ADMIN - * OPTION. - */ - if (roleform->rolsuper && !superuser()) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute may drop roles with the %s attribute.", - "SUPERUSER", "SUPERUSER"))); - if (!is_admin_of_role(GetUserId(), roleid)) - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("permission denied to drop role"), - errdetail("Only roles with the %s attribute and the %s option on role \"%s\" may drop this role.", - "CREATEROLE", "ADMIN", NameStr(roleform->rolname)))); - /* DROP hook for the role being removed */ InvokeObjectDropHook(AuthIdRelationId, roleid, 0); - /* Don't leak the syscache tuple */ - ReleaseSysCache(tuple); - - /* - * Lock the role, so nobody can add dependencies to her while we drop - * her. We keep the lock until the end of transaction. - */ - LockSharedObject(AuthIdRelationId, roleid, 0, AccessExclusiveLock); - /* * If there is a pg_auth_members entry that has one of the roles to be * dropped as the roleid or member, it should be silently removed, but @@ -1484,6 +1584,21 @@ RenameRole(const char *oldname, const char *newname) return address; } +/* + * Before acquiring a role lock for GRANT/REVOKE, check that the current user + * has authorization to grant/revoke membership in the specified role. + */ +static void +RoleNameCallbackForGrantRole(const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg) +{ + bool is_grant = *((bool *) callback_arg); + Oid currentUserId = GetUserId(); + + check_role_membership_authorization(currentUserId, roleid, is_grant); +} + + /* * GrantRoleStmt * @@ -1568,9 +1683,14 @@ GrantRole(ParseState *pstate, GrantRoleStmt *stmt) (errcode(ERRCODE_INVALID_GRANT_OPERATION), errmsg("column names cannot be included in GRANT/REVOKE ROLE"))); - roleid = get_role_oid(rolename, false); - check_role_membership_authorization(currentUserId, - roleid, stmt->is_grant); + /* + * Use RoleNameGetOid to resolve the name, check permissions, and lock + * the role atomically with a retry loop. This prevents race + * conditions where a concurrent DROP commits between name resolution + * and lock acquisition. + */ + roleid = RoleNameGetOid(rolename, ShareUpdateExclusiveLock, false, + RoleNameCallbackForGrantRole, &stmt->is_grant); if (stmt->is_grant) AddRoleMems(currentUserId, rolename, roleid, stmt->grantee_roles, grantee_ids, diff --git a/src/include/commands/user.h b/src/include/commands/user.h index 97dcb93791b..17263452250 100644 --- a/src/include/commands/user.h +++ b/src/include/commands/user.h @@ -21,6 +21,15 @@ extern PGDLLIMPORT int Password_encryption; /* values from enum PasswordType */ extern PGDLLIMPORT char *createrole_self_grant; +/* Callback for RoleNameGetOid, invoked after name resolution but before locking */ +typedef void (*RoleNameGetOidCallback) (const char *rolename, Oid roleid, + Oid oldroleid, void *callback_arg); + +extern Oid RoleNameGetOid(const char *rolename, LOCKMODE lockmode, + bool missing_ok, + RoleNameGetOidCallback callback, + void *callback_arg); + /* Hook to check passwords in CreateRole() and AlterRole() */ typedef void (*check_password_hook_type) (const char *username, const char *shadow_pass, PasswordType password_type, Datum validuntil_time, bool validuntil_null); -- 2.34.1 --fotX2lJRknAHpfH+ Content-Type: text/x-diff; charset=us-ascii Content-Disposition: attachment; filename="v4-0002-Protect-role-resolution-in-roleSpecsToIds-against.patch" ^ permalink raw reply [nested|flat] 785+ messages in thread
end of thread, other threads:[~2026-07-06 08:28 UTC | newest] Thread overview: 785+ messages (download: mbox mbox.gz follow: Atom feed) -- links below jump to the message on this page -- 2023-11-16 02:30 [PATCH v6 2/2] Provide relfilenode statistics Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-04 04:47 [PATCH v1] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 1/2] Add RoleNameGetOid() with invalidation-based retry loop for DropRole()/GrantRole() 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